Стартап продаёт продукты питания. Дизайнеры предложили поменять шрифты во всём приложении, но менеджеры считают, что пользователям будет непривычно. Нужно разобраться, как ведут себя пользователи мобильного приложения, а также по результатам A/A/B-теста принять решение, какой шрифт лучше.
Цель проекта - подготовка рекомендаций по целесообразности изменения шрифтов в мобильном приложении на основе изучения поведения его пользователей и результатов А/A/B-теста.
Для этого необходимо изучить:
Каждая запись в логе — это действие пользователя, или событие:
# импортируем необходимые библиотеки
import pandas as pd
import numpy as np
import scipy.stats as stats
import math as mth
import plotly
import plotly.express as px
from plotly import graph_objects as go
import plotly.io as pio
pio.templates.default = 'seaborn'
# %%HTML
# <style type="text/css">
# table.dataframe td, table.dataframe th {
# border: 1px black solid !important;
# color: black !important;
# }
# прочитаем DataFrame
try:
df = pd.read_csv('logs_exp.csv', sep = '\t') # локальный путь
except:
df = pd.read_csv('/datasets/logs_exp.csv', sep = '\t') # путь на сервере
# выведем на экран 10 верхних строк таблицы
df.head(10)
# посмотрим сводную информацию таблицы
df.info()
В таблице 244126 строк, 4 столбца, тип данных у одного столбца строковый, у остальных - целочисленный. Для удобства необходимо заменить названия столбцов, а также значения в столбце с названиями событий. В столбце EventTimestamp значения времени события указаны в формате unix time.
# посмотрим количество уникальных значений по столбцам таблицы
for column in df.columns:
print(column)
print()
print(df[column].value_counts())
print()
Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения, посещение страницы с предложениями товара, помещение товара в корзину, оплата товара и посещение страницы с инструкцией по работе приложения, наибольшее количество из них - посещение главной страницы (119205 раз), наименьшее - посещение страницы с инструкцией по работе приложения (1052 раза). Действия осуществлялись 7551 уникальным пользователем, максимальное количество действий, совершенных одним пользователем - 2308. Действия совершались пользователями трех различных групп, наибольшее их количество произведено пользователями группы 248 (экспериментальной группы) - 85747.
# определим период информации в таблице
round((df['EventTimestamp'].max() - df['EventTimestamp'].min()) / 86400, 0)
В таблице представлена информация за 14 дней.
# определим количество пропущенных значений в таблице
df.isnull().sum()
Пропущенные значения отсутствуют.
# посчитаем количество дубликатов
df.duplicated().sum()
Выявлено 413 повторяющихся строк.
# посчитаем долю дубликатов в таблице
round(df.duplicated().sum() / len(df) * 100, 2)
Повторяющихся строк - 0,17%.
При изучении таблицы с данными установлено следующее:
EventTimestamp значения времени события указаны в формате unix time; Проанализировав вышеизложенное, необходимо выполнить следующее:
EventName с названиями событий; EventTimestamp привести к формату datetime; # заменим названия столбцов
df.columns = ['event_name',
'user_id',
'event_time',
'group']
df.head()
# заменим значения в столбце с названиями событий
df['event_name'] = df['event_name'].str.replace('MainScreenAppear', 'main_screen')
df['event_name'] = df['event_name'].str.replace('OffersScreenAppear', 'offer_screen')
df['event_name'] = df['event_name'].str.replace('CartScreenAppear', 'cart_screen')
df['event_name'] = df['event_name'].str.replace('PaymentScreenSuccessful', 'payment_screen')
df['event_name'] = df['event_name'].str.replace('Tutorial', 'tutorial')
df['event_name'].unique()
# приведем значения времени события к формату "datetime"
df['event_time'] = pd.to_datetime(df['event_time'],
unit = 's')
df.head()
# добавим в таблицу столбец с датой события
df['event_date'] = df['event_time'].astype('datetime64[D]')
df.head()
# удалим дубликаты
df = df.drop_duplicates()\
.reset_index(drop = True)
df.info()
В целях подготовки данных провели следующую работу:
datetime; # определим количество событий
df['event_name'].count()
Всего в логе 243713 событий.
# определим количество уникальных пользователей
df['user_id'].nunique()
Всего в логе 7551 уникальный пользователь.
# создадим таблицу с количеством событий в логе по пользователям
event_users = df.groupby('user_id')\
.agg(event_count = ('event_name', 'count'))\
.reset_index()
event_users.head()
# изучим статистическую информацию количества событий по пользователям
event_users['event_count'].describe()
Среднее арифметическое количества событий на одного пользователя - 32, медиана - 20. Наблюдается очень большой разброс этого показателя: минимальное количество событий на одного пользователя - 1, максимальное - 2307.
# построим график распределения количества событий по числу пользователей
fig = px.histogram(event_users,
x = 'event_count',
marginal = 'box')
fig.update_layout(title = 'Распределение количества событий по числу пользователей',
xaxis_title = 'Количество событий',
yaxis_title = 'Количество пользователей',
margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Событий: %{x}<br>Пользователей: %{y}')
fig.show()
Так как распределение не является нормальным, имеется большое количество аномальных значений (выбросов), в качестве среднего целесообразнее использовать медиану.
Таким образом, в среднем на одного пользователя приходится 20 событий.
# определим, за какой период мы располагаем данными
df['event_date'].astype('str').unique()
Мы располагаем данными за период с 25 июля по 7 августа 2019 года (14 дней).
# определим минимальные и максимальные время и дату событий
df['event_time'].min(), df['event_time'].max()
Минимальные дата и время события - 25 июля 2019 года 4 часа 43 минуты 36 секунд, максимальные - 7 августа 2019 года 21 час 15 минут 17 секунд.
# построим график распределения количества событий по времени
fig = px.histogram(df,
x = 'event_time')
fig.update_layout(title = 'Распределение количества событий по времени',
xaxis = dict(title = 'Дата',
tickformat = '%d.%m',
hoverformat = '%H.%M'),
yaxis_title = 'Количество событий',
margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Время: %{x}<br>Событий: %{y}')
fig.show()
Исходя из графика распределения заметно, что у нас имеются неполные данные до 31 июля 2019 года включительно. Вероятнее всего, подобный "перекос" данных связан с тем, в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого. Таким образом, для анализа целесообразно использовать данные с 1 августа 2019 года. Вместе с тем, на графике видно, что справа (в конце анализируемого периода) отсутствуют данные за несколько часов 7 августа 2019 года. Это можно исправить, удалив данные за этот день, но поскольку их часть и так необходимо исключить из анализа, 7 августа 2019 года оставим в анализируемом периоде.
Таким образом актуальный период для исследования - с 1 по 7 августа 2019 года (7 дней).
# удалим из таблицы данные за неактуальный период
df_new = df.query('event_date >= "2019-08-01"')\
.reset_index(drop = True)
df_new.head()
Проверим, есть ли пользователи, которые воспользовались приложением, минуя его главную страницу.
# создадим таблицу с количеством событий по их видам по пользователям
main_screen_isna = df_new.pivot_table(index = 'user_id',
columns = 'event_name',
values = 'event_time',
aggfunc = 'count')\
.reset_index()
main_screen_isna.head()
# посчитаем количество пользователей, которые воспользовались приложением, минуя его главную страницу
main_screen_isna[main_screen_isna['main_screen'].isna()]['user_id'].count()
115 пользователей воспользовались приложением, минуя его главную страницу.
# создадим список с пользователями, которые воспользовались приложением, минуя его главную страницу
main_screen_isna_list = main_screen_isna[main_screen_isna['main_screen'].isna()]['user_id'].to_list()
main_screen_isna_list
# создадим таблицу с количеством событий и пользователей, которые воспользовались приложением, минуя его главную страницу,
# по видам событий
df_new.query('user_id == @main_screen_isna_list').groupby('event_name')\
.agg(event_count = ('event_time', 'count'),
user_count = ('user_id', 'nunique'))\
.sort_values(by = 'user_count',
ascending = False)\
.reset_index()
Из 115 пользователей, которые воспользовались приложением, минуя его главную страницу, 111 сразу посетили страницу с предложениями товара, а 4 - страницу с инструкцией по работе приложения.
# создадим таблицу с количеством событий и пользователей, которые воспользовались приложением, минуя его главную страницу,
# по группам
df_new.query('user_id == @main_screen_isna_list').groupby('group')\
.agg(event_count = ('event_time', 'count'),
user_count = ('user_id', 'nunique'))\
.reset_index()
# построим график распределения количества событий, совершенных пользователями, которые воспользовались приложением,
# минуя его главную страницу, по времени
fig = px.histogram(df_new.query('user_id == @main_screen_isna_list'),
x = 'event_time')
fig.update_layout(title = 'Распределение количества событий по времени',
xaxis = dict(title = 'Дата',
tickformat = '%d.%m',
hoverformat = '%H.%M'),
yaxis_title = 'Количество событий',
margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Время: %{x}<br>Событий: %{y}')
fig.show()
События, совершенные пользователями, которые воспользовались приложением, минуя его главную страницу, распределены по времени и группам равномерно. Оставим строки с такими пользователями в таблице. Вместе с тем, при построении в дальнейшем воронки событий необходимо учесть, что часть пользователей главную страницу приложения не посещали.
# определим количество событий за актуальный период
df_new['event_name'].count()
# посчитаем долю "потерянных" событий
round((df['event_name'].count() - df_new['event_name'].count()) / df['event_name'].count() * 100, 2)
# определим количество уникальных пользователей за актуальный период
df_new['user_id'].nunique()
# посчитаем долю "потерянных" пользователей
round((df['user_id'].nunique() - df_new['user_id'].nunique()) / df['user_id'].nunique() * 100, 2)
Всего для анализа в актуальном периоде осталось 240887 событий и 7534 уникальных пользователя. Отбросив часть данных, было "потеряно" 1,16% событий и 0,23% пользователей.
# создадим таблицу с количеством пользователей в логе по группам по "сырым" данным
event_users_group = df.groupby('group')\
.agg(user_count = ('user_id', 'nunique'))\
.reset_index()
event_users_group
# создадим таблицу с количеством пользователей в логе по группам за актуальный период
event_users_group_new = df_new.groupby('group')\
.agg(user_count = ('user_id', 'nunique'))\
.reset_index()
event_users_group_new
# построим график количества пользователей в логе по группам
fig = go.Figure()
fig.add_trace(go.Bar(x = event_users_group['group'],
y = event_users_group['user_count'],
name = 'весь период'))
fig.add_trace(go.Bar(x = event_users_group_new['group'],
y = event_users_group_new['user_count'],
name = 'актуальный период'))
fig.update_layout(title = 'Количество пользователей в логе по группам',
xaxis = dict(title = 'Группа',
tickmode = 'array',
tickvals = [246, 247, 248]),
yaxis_title = 'Количество пользователей',
margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.update_traces(hovertemplate = 'Группа: %{x}<br>Пользователей: %{y}')
fig.show()
Количество пользователей в контрольных и экспериментальной группах отличается незначительно. Отбросив часть данных, соотношение пользователей по группам изменилось слабо, так как пользователи были исключены равномерно из всех групп.
Всего в логе 243713 событий, которые совершил 7551 уникальный пользователь.
Среднее арифметическое количества событий на одного пользователя - 32, медиана - 20. Наблюдается очень большой разброс этого показателя: минимальное количество событий на одного пользователя - 1, максимальное - 2307. Так как распределение количества событий по числу пользователей не является нормальным, имеется большое количество аномальных значений (выбросов), в качестве среднего целесообразнее использовать медиану, которая равна 20.
Всего в логе данные за период с 25 июля по 7 августа 2019 года (14 дней). Минимальные дата и время события - 25 июля 2019 года 4 часа 43 минуты 36 секунд, максимальные - 7 августа 2019 года 21 час 15 минут 17 секунд.
Исходя из графика распределения количества событий по времени заметно, что у нас имеются неполные данные до 31 июля 2019 года включительно. Вероятнее всего, подобный "перекос" данных связан с тем, в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого. Таким образом, для анализа целесообразно использовать данные с 1 августа 2019 года. Вместе с тем, на графике видно, что справа (в конце анализируемого периода) отсутствуют данные за несколько часов 7 августа 2019 года. Это можно исправить, удалив данные за этот день, но поскольку их часть и так необходимо исключить из анализа, 7 августа 2019 года оставили в анализируемом периоде. Таким образом актуальный период для исследования - с 1 по 7 августа 2019 года (7 дней).
115 пользователей воспользовались приложением, минуя его главную страницу, из них 111 сразу посетили страницу с предложениями товара, а 4 - страницу с инструкцией по работе приложения.
События, совершенные пользователями, которые воспользовались приложением, минуя его главную страницу, распределены по времени и группам равномерно, поэтому оставили строки с такими пользователями в таблице.
Всего для анализа в актуальном периоде осталось 240887 событий и 7534 уникальных пользователя. Отбросив часть данных, было "потеряно" 1,16% событий и 0,23% пользователей.
Количество пользователей в контрольных и экспериментальной группах отличается незначительно. Отбросив часть данных, соотношение пользователей по группам изменилось слабо, так как пользователи были исключены равномерно из всех групп.
# создадим таблицу с количеством событий по их видам
event_count = df_new.groupby('event_name')\
.agg(event_count = ('user_id', 'count'))\
.sort_values(by = 'event_count',
ascending = False)\
.reset_index()
event_count
# построим график количества событий по их видам
fig = px.bar(event_count,
x = 'event_name',
y = 'event_count',
color = 'event_name')
fig.update_layout(title = 'Количество событий по их видам',
xaxis_title = 'Вид события',
yaxis_title = 'Количество событий',
showlegend = False,
margin = dict(l = 0, r = 0, t = 70, b = 20))
fig.update_traces(hovertemplate = 'Вид: %{x}<br>Событий: %{y}')
fig.show()
Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения, посещение страницы с предложениями товара, помещение товара в корзину, оплата товара и посещение страницы с инструкцией по работе приложения, наибольшее количество из них - посещение главной страницы - 117328 раз, посещение страницы с предложениями товара производилось 46333 раза, помещение товара в корзину - 42303 раза, оплата товара - 33918 раз, меньше всего совершено посещений страницы с инструкцией по работе приложения - 1005 раз.
Создадим таблицу с количеством пользователей по видам событий, при этом, учитывая, что не все пользователи посещали главную страницу приложения, добавим в таблицу строку с общим количеством пользователей.
# создадим таблицу с количеством пользователей по видам событий
event_users_count = df_new.groupby('event_name')\
.agg(user_count = ('user_id', 'nunique'))\
.sort_values(by = 'user_count',
ascending = False)\
.reset_index()
event_users_count.loc[-1] = ['all_users', df_new['user_id'].nunique()]
event_users_count.index = event_users_count.index + 1
event_users_count = event_users_count.sort_index()
event_users_count
# построим график количества пользователей по видам событий
fig = px.bar(event_users_count.query('event_name != "all_users"'),
x = 'event_name',
y = 'user_count',
color = 'event_name')
fig.update_layout(title = 'Количество пользователей по видам событий',
xaxis_title = 'Вид события',
yaxis_title = 'Количество пользователей',
showlegend = False,
margin = dict(l = 0, r = 0, t = 70, b = 20))
fig.update_traces(hovertemplate = 'Вид: %{x}<br>Пользователей: %{y}')
fig.show()
Наибольшее количество пользователей посещали главную страницу приложения - 7419, посещали страницу с предложениями товара 4593 пользователя, помещали товар в корзину - 3734, оплачивали товар - 3539, меньше всего пользователей посещали страницу с инструкцией по работе приложения - 840.
# добавим в таблицу столбец с долей пользователей, которые хоть раз совершали событие
event_users_count['ratio'] = round(event_users_count['user_count'] / df_new['user_id'].nunique() * 100, 1)
event_users_count
Посещали главную страницу приложения 98,5% всех уникальных пользователей, 61,0% посещали страницу с предложениями товара, 49,6% помещали товар в корзину, 47,0% оплачивали товар, меньше всего пользователей посещали страницу с инструкцией по работе приложения - 11,1%.
События выстраиваются в последовательную цепочку в следующем порядке:
Посещение страницы с инструкцией по работе приложения при построении воронки событий учитывать не следует, так как это событие может происходить после каждого из перечисленных шагов либо являться первым посещением приложения.
# удалим строку с посещениями страницы с инструкцией по работе приложения
event_users_count = event_users_count.query('event_name != "tutorial"')
event_users_count
Не все пользователи идут по ожидаемому пути, создадим таблицу с количеством пользователей по видам событий с учётом порядка действий.
# создадим таблицу со временем первого совершения каждого события
users = df_new.pivot_table(index = 'user_id',
columns = 'event_name',
values = 'event_time',
aggfunc = 'min')\
.reset_index()
users.head()
# создадим таблицу с количеством пользователей по видам событий с учётом порядка действий
step_1 = ~users['main_screen'].isna()
step_2 = step_1 & (users['offer_screen'] >= users['main_screen'])
step_3 = step_2 & (users['cart_screen'] >= users['offer_screen'])
step_4 = step_3 & (users['payment_screen'] >= users['cart_screen'])
n_main_screen = users[step_1].shape[0]
n_offer_screen = users[step_2].shape[0]
n_cart_screen = users[step_3].shape[0]
n_payment_screen = users[step_4].shape[0]
data = {'event_name': ['main_screen', 'offer_screen', 'cart_screen', 'payment_screen'],
'user_count_step': [n_main_screen, n_offer_screen, n_cart_screen, n_payment_screen]}
event_users_count_step = pd.DataFrame(data = data)
event_users_count_step.loc[-1] = ['all_users', df_new.query('user_id != @main_screen_isna_list')['user_id'].nunique()]
event_users_count_step.index = event_users_count_step.index + 1
event_users_count_step = event_users_count_step.sort_index()
event_users_count_step['ratio_step'] = round(event_users_count_step['user_count_step']\
/ df_new.query('user_id != @main_screen_isna_list')['user_id'].nunique() * 100, 1)
event_users_count_step
# объединим таблицы с количеством пользователей по видам событий
event_users_count = event_users_count.merge(event_users_count_step,
on = 'event_name')
event_users_count
Если учитывать порядок действий пользователей в приложении, то после посещения главной страницы 56,6% из них посетили страницу с предложениями товара, 24,2% поместили товар в корзину, 18,3% оплатили товар.
Посмотрим, на каком этапе пользователи чаще всего посещают страницу с инструкцией по работе приложения.
users[~users['tutorial'].isna() & (users['tutorial'] < users['main_screen'])].shape[0]
787 пользователей из 840 (93,7%) посещали страницу с инструкцией по работе приложения перед первым визитом на главную страницу.
# добавим в таблицу столбцы с долей пользователей, проходящих на следующий шаг воронки (от числа пользователей на предыдущем)
event_users_count['ratio_previos'] = round(event_users_count['user_count']\
/ event_users_count['user_count']\
.shift(periods = 1,
axis = 0,
fill_value = event_users_count['user_count'][0]) * 100, 1)
event_users_count['ratio_step_previos'] = round(event_users_count['user_count_step']\
/ event_users_count['user_count_step']\
.shift(periods = 1,
axis = 0,
fill_value = event_users_count['user_count_step'][0]) * 100, 1)
event_users_count
# построим воронку событий
fig = go.Figure()
fig.add_trace(go.Funnel(x = event_users_count['user_count'],
y = event_users_count['event_name'],
textinfo = 'value + percent initial + percent previous',
hoverinfo = 'x + y + percent initial + percent previous'))
fig.update_layout(title = 'Воронка событий в мобильном приложении',
margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.show()
# построим воронку событий с учетом порядка действий
fig = go.Figure()
fig.add_trace(go.Funnel(x = event_users_count.query('event_name != "all_users"')['user_count_step'],
y = event_users_count.query('event_name != "all_users"')['event_name'],
textinfo = 'value + percent initial + percent previous',
hoverinfo = 'x + y + percent initial + percent previous'))
fig.update_layout(title = 'Воронка событий в мобильном приложении с учетом порядка действий',
margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.show()
Если не учитывать порядок действий, то посещали главную страницу приложения 98,5% пользователей, 61,9% от них посетили страницу с предложениями товара, 81,3% от них поместили товар в корзину, 94,8% от них оплатили товар.
Если учесть порядок действий, то из всех пользователей, посетивших главную страницу приложения, 56,6% затем посетили страницу с предложениями товара, 42,7% из них затем поместили товар в корзину, 75,7% из них затем оплатили товар.
Если не учитывать порядок действий, то наибольшее снижение количества пользователей при переходе с главной страницы приложения на страницу с предложениями товара - 38,1%, наименьшее - на шаге прехода из корзины с товаром к его оплате - 5,2%.
Если учесть порядок действий, то больше всего пользователей теряется при переходе со страницы с предложениями товара к корзине с товаром - 57,3%, меньше всего - на шаге прехода из корзины с товаром к его оплате - 24,3%.
# посчитаем долю пользователей, которая доходит от первого события до оплаты
ratio_main_payment = round(event_users_count.query('event_name == "payment_screen"')['user_count'].sum()\
/ event_users_count.query('event_name == "main_screen"')['user_count'].sum() * 100, 1)
ratio_main_payment
# посчитаем долю пользователей, которая доходит от первого события до оплаты, с учетом порядка действий
ratio_main_payment_step = round(event_users_count.query('event_name == "payment_screen"')['user_count_step'].sum()\
/ event_users_count.query('event_name == "main_screen"')['user_count_step'].sum() * 100, 1)
ratio_main_payment_step
Из всех пользователей, посетивших главную страницу приложения, 47,7% произвели оплату товара.
Всю последовательную цепочку событий от посещения главной страницы до оплаты товара прошли 18,3% пользователей.
Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения, посещение страницы с предложениями товара, помещение товара в корзину, оплата товара и посещение страницы с инструкцией по работе приложения, наибольшее количество из них - посещение главной страницы - 117328 раз, посещение страницы с предложениями товара производилось 46333 раза, помещение товара в корзину - 42303 раза, оплата товара - 33918 раз, меньше всего совершено посещений страницы с инструкцией по работе приложения - 1005 раз.
Наибольшее количество пользователей посещали главную страницу приложения - 7419 (98,5%), посещали страницу с предложениями товара 4593 пользователя (61,0%), помещали товар в корзину - 3734 (49,6%), оплачивали товар - 3539 (47,0%), меньше всего пользователей посещали страницу с инструкцией по работе приложения - 840 (11,1%).
События выстраиваются в последовательную цепочку в следующем порядке:
Посещение страницы с инструкцией по работе приложения при построении воронки событий учитывать не следует, так как это событие может происходить после каждого из перечисленных шагов либо являться первым посещением приложения. Во время дальнейшего анализа установлено, что 787 пользователей из 840 (93,7%) посещали страницу с инструкцией по работе приложения перед первым визитом на главную страницу.
Если не учитывать порядок действий пользователей, то:
Если учитывать порядок действий пользователей в приложении, то:
# проверим, имеются ли пользователи, попавшие в разные контрольные и экспериментальную группы
(df_new.groupby('user_id')['group'].nunique() > 1).sum()
Во всех трех группах находятся только уникальные пользователи.
# посмотрим на распределение пользователей по группам
event_users_group_new
# построим график соотношения пользователей по группам
fig = go.Figure()
fig.add_trace(go.Pie(labels = event_users_group_new['group'],
values = event_users_group_new['user_count'],
textinfo = 'value + percent',
hole = 0.35))
fig.update_layout(annotations = [dict(text = 'Соотношение<br>пользователей<br>по группам',
font_size = 20,
showarrow = False)],
legend = dict(x = 0.7,
font_size = 16),
margin = dict(l = 0, r = 0, t = 20, b = 20))
fig.update_traces(textposition = 'inside',
textfont_size = 16)
fig.show()
Пользователи между группами распределены практически равномерно: в 246 группе 2484 пользователя (33,0% от их общего количества), в 247 группе 2513 пользователей (33,3% от их общего количества), в 248 группе 2537 пользователей (33,7% от их общего количества).
Сначала создадим таблицу с количеством пользователей, долей пользователей, которые хоть раз совершали событие, долей пользователей, проходящих на следующий шаг воронки, по видам событий и группам
# создадим функцию для формирования таблицы
def event_group(group):
result = df_new.query('group == @group & event_name != "tutorial"')\
.groupby('event_name')\
.agg(users = ('user_id', 'nunique'))\
.sort_values(by = 'users',
ascending = False)\
.reset_index()
result.loc[-1] = ['all_users', df_new.query('group == @group')['user_id'].nunique()]
result.index = result.index + 1
result = result.sort_index()
result['ratio'] = round(result['users'] / df_new.query('group == @group')['user_id'].nunique(), 3)
result['ratio_previos'] = round(result['users']\
/ result['users'].shift(periods = 1,
axis = 0,
fill_value = result['users'][0]), 3)
return result
# создадим таблицу
event_group_246 = event_group(246)
event_group_247 = event_group(247)
event_group_248 = event_group(248)
event_group = event_group_246.merge(event_group_247,
on = 'event_name',
suffixes = (None, '_247'))
event_group = event_group.merge(event_group_248,
on = 'event_name',
suffixes = ('_246', '_248'))
event_group
# построим воронку событий в разрезе групп
fig = go.Figure()
fig.add_trace(go.Funnel(x = event_group['users_246'],
y = event_group['event_name'],
textinfo = 'value + percent initial + percent previous',
hoverinfo = 'name + x + y + percent initial + percent previous',
name = '246'))
fig.add_trace(go.Funnel(x = event_group['users_247'],
y = event_group['event_name'],
textinfo = 'value + percent initial + percent previous',
hoverinfo = 'name + x + y + percent initial + percent previous',
name = '247'))
fig.add_trace(go.Funnel(x = event_group['users_248'],
y = event_group['event_name'],
textinfo = 'value + percent initial + percent previous',
hoverinfo = 'name + x + y + percent initial + percent previous',
name = '248'))
fig.update_layout(title = 'Воронка событий в мобильном приложении в разрезе групп',
margin = dict(l = 0, r = 0, t = 70, b = 0))
fig.show()
Визуально, на первый взгляд, кажется, что группа 246 немного успешнее, чем другие, необходимо проверить это статистическими методами. Однако, в первую очередь нужно установить, есть ли различие между контрольными группами по размеру. В абсолютном выражении разница в количестве пользователей - 29, определим, является ли эта разница статистически значимой. Для этого проверим гипотезу о равенстве пропорций двух генеральных совокупностей при помощи z-критерия.
Критический уровень статистической значимости определим в 5%.
# зададим критический уровень статистической значимости
alpha = .05
Сформулируем гипотезы:
H0 - различий в долях двух контрольных групп нет;
H1 - различия в долях двух контрольных групп есть.
# создадим функцию для проверки гипотезы
def z_test_proportion(test_group_1, test_group_2):
successes = np.array([event_group.query('event_name == "all_users"')[str('users_') + str(test_group_1)].sum(),
event_group.query('event_name == "all_users"')[str('users_') + str(test_group_2)].sum()])
trials = np.array([df_new['user_id'].nunique(), df_new['user_id'].nunique()])
p1 = successes[0] / trials[0]
p2 = successes[1] / trials[1]
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
distr = stats.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', round(p_value, 4))
if (p_value < alpha):
print('Отвергаем нулевую гипотезу: между долями есть значимые различия.')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.')
# проверим гипотезу
z_test_proportion(246, 247)
Полученное p-значение оказалось больше уровня значимости в 5%, следовательно нулевую гипотезу не отвергаем: различий в долях двух контрольных групп нет.
Для определения наличия отличий между контрольными группами проверим гипотезу о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий.
Критический уровень статистической значимости определим в 5%. Учитывая, что будет проведено 4 попарных сравнения, к уровню статистической значимости применим поправку Бонферрони.
Сформулируем гипотезы:
H0 - различий в долях двух контрольных групп нет;
H1 - различия в долях двух контрольных групп есть.
# применим поправку Бонферрони к уровню значимости
bonferroni_alpha = alpha / 4
# создадим функцию для проверки гипотезы
def z_test(test_group_1, test_group_2, event_name):
successes = np.array([event_group.query('event_name == @event_name')[str('users_') + str(test_group_1)].sum(),
event_group.query('event_name == @event_name')[str('users_') + str(test_group_2)].sum()])
trials = np.array([event_group.query('event_name == "all_users"')[str('users_') + str(test_group_1)].sum(),
event_group.query('event_name == "all_users"')[str('users_') + str(test_group_2)].sum()])
p1 = successes[0] / trials[0]
p2 = successes[1] / trials[1]
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
distr = stats.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', round(p_value, 4))
if (p_value < bonferroni_alpha):
print('Отвергаем нулевую гипотезу: между долями есть значимые различия.')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.')
# проверим гипотезу для контрольных групп 246 и 247
print('Проверка гипотезы для контрольных групп 246 и 247')
print()
print('Уровень статистической значимости: ', bonferroni_alpha)
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
print('Шаг цепочки событий: ', name)
z_test(246, 247, name)
print()
Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях двух контрольных групп нет.
Критерии успешного A/A-теста:
Проведенный анализ говорит о том, что все критерии успешного А/А теста в исследовании соблюдены,таким образом разбиение на контрольные группы корректно, можно приступать к проведению А/В теста.
# добавим в таблицу столбец с количеством пользователей объединенной контрольной группы
event_group['users_union'] = event_group['users_246'] + event_group['users_247']
event_group
Для определения наличия отличий между контрольными и экспериментальной группами проверим гипотезу о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.
Критический уровень статистической значимости определим в 5%. Учитывая, что для каждой из контрольных групп, а также объединенной контрольной группы будет проведено по 4 попарных сравнения с экспериментальной группой, к уровню статистической значимости применим поправку Бонферрони.
Сформулируем гипотезы:
H0 - различий в долях контрольной и экспериментальной групп нет;
H1 - различия в долях контрольной и экспериментальной групп есть.
Проверим гипотезу о различиях в долях групп 246 и 248.
# проверим гипотезу для групп 246 и 248
print('Проверка гипотезы для групп 246 и 248')
print()
print('Уровень статистической значимости: ', bonferroni_alpha)
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
print('Шаг цепочки событий: ', name)
z_test(246, 248, name)
print()
Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях групп 246 и 248 нет.
Проверим гипотезу о различиях в долях групп 247 и 248.
# проверим гипотезу для групп 247 и 248
print('Проверка гипотезы для групп 247 и 248')
print()
print('Уровень статистической значимости: ', bonferroni_alpha)
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
print('Шаг цепочки событий: ', name)
z_test(247, 248, name)
print()
Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях групп 247 и 248 нет.
Проверим гипотезу о различиях в долях группы 248 и объединенной группы.
# проверим гипотезу для группы 248 и объединенной группы
print('Проверка гипотезы для группы 248 и объединенной группы')
print()
print('Уровень статистической значимости: ', bonferroni_alpha)
print()
for name in event_group.query('event_name != "all_users"')['event_name']:
print('Шаг цепочки событий: ', name)
z_test('union', 248, name)
print()
Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше уровня значимости в 5% с учетом поправки на множественные сравнения, следовательно нулевую гипотезу не отвергаем: различий в долях группы 248 и объединенной группы нет.
Ни одна из проведенных проверок не показала значимых различий между долями, поэтому отсутствуют основания считать, что изменение шрифта в мобильном приложении влияет на поведение пользователей.
Всего при проведении А/В теста было сделано 12 проверок статистических гипотез: для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.
При проверке статистических гипотез был принят критический уровень статистической значимости в 5% с учетом поправки на множественные сравнения - это вероятность ошибочно отвергнуть нулевую гипотезу. При увеличении статистической значимости увеличится критический диапазон, при попадании в который нулевая гипотеза будет отвергнута, — возрастет вероятность ошибки первого рода (будет больше ложных срабатываний), вместе с тем уменьшится вероятность ошибки второго рода - ошибочно принять нулевую гипотезу при верной альтернативной. В проведенном исследовании при уровне значимости, например в 10%, с учетом поправки на множественные сравнения все результаты проверки статистических гипотез оказались бы неизменными. Таким образом уровень статистической значимости менять нецелесообразно.
Перед проведением А/В теста был проведен А/А тест. Критерии успешного A/A-теста:
Во всех трех группах находятся только уникальные пользователи. При этом, пользователи между группами распределены практически равномерно: в 246 группе 2484 пользователя (33,0% от их общего количества), в 247 группе 2513 пользователей (33,3% от их общего количества), в 248 группе 2537 пользователей (33,7% от их общего количества).
Для того, чтобы определить, является ли различие в количестве пользователей в разных группах статистически значимым, была проверена гипотеза о равенстве пропорций двух генеральных совокупностей при помощи z-критерия, критический уровень статистической значимости при этом был определен в размере в 5%. Полученное p-значение оказалось больше установленного уровня значимости, что позволило не отвергнуть нулевую гипотезу и сделать вывод об отсутствии различий в долях двух контрольных групп.
Кроме этого, для определения наличия отличий между контрольными группами была проверена гипотеза о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий, критический уровень статистической значимости при этом был определен в размере 5% (учитывая, что проведено 4 попарных сравнения, к уровню статистической значимости применили поправку Бонферрони). Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше установленного уровня значимости с учетом поправки на множественные сравнения, что позволило не отвергнуть нулевую гипотезу и сделать вывод об отсутствии различий в долях двух контрольных групп.
Таким образом, исходя из проведенного анализа, все критерии успешного А/А теста в исследовании были соблюдены, разбиение на контрольные группы произведено корректно, что позволило приступить к проведению А/В теста.
Для определения наличия отличий между контрольными и экспериментальной группами (А/В тест) была проверена гипотеза о равенстве пропорций двух генеральных совокупностей при помощи z-критерия для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной, критический уровень статистической значимости при этом был определен в размере 5% (учитывая, что проведено 4 попарных сравнения, к уровню статистической значимости применили поправку Бонферрони). Полученное p-значение при проверке гипотезы для каждого шага цепочки событий оказалось больше установленного уровня значимости с учетом поправки на множественные сравнения, что позволило не отвергнуть нулевую гипотезу и сделать вывод об отсутствии различий в долях каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.
Так как ни одна из проведенных проверок не показала значимых различий между долями, отсутствуют основания считать, что изменение шрифта в мобильном приложении влияет на поведение пользователей.
Всего при проведении А/В теста было сделано 12 проверок статистических гипотез: для каждого шага цепочки событий каждой из контрольных групп, а также объединенной контрольной группы и экспериментальной.
При проверке статистических гипотез был принят критический уровень статистической значимости в 5% с учетом поправки на множественные сравнения - это вероятность ошибочно отвергнуть нулевую гипотезу. При увеличении статистической значимости увеличится критический диапазон, при попадании в который нулевая гипотеза будет отвергнута, — возрастет вероятность ошибки первого рода (будет больше ложных срабатываний), вместе с тем уменьшится вероятность ошибки второго рода - ошибочно принять нулевую гипотезу при верной альтернативной. В проведенном исследовании при уровне значимости, например в 10%, с учетом поправки на множественные сравнения все результаты проверки статистических гипотез оказались бы неизменными. Таким образом уровень статистической значимости менять нецелесообразно.
Всего имеется информация о 243713 событиях, которые совершил 7551 уникальный пользователь, среднее количество событий на одного пользователя - 20.
Всего в логе данные за период с 25 июля по 7 августа 2019 года (14 дней), однако информация до 31 июля 2019 года включительно неполная, таким образом актуальный период для исследования - с 1 по 7 августа 2019 года (7 дней). Отбросив часть данных, всего для анализа в актуальном периоде осталось 240887 событий и 7534 уникальных пользователя, "потеряно" 1,16% событий и 0,23% пользователей.
Пользователями в приложении совершались 5 различных действий - посещение главной страницы приложения (117328 раз, совершенных 7419 пользователями, что составляет 98,5% от их общего количества), посещение страницы с предложениями товара (46333 раза, совершенных 4593 пользователями, что составляет 61,0% от их общего количества), помещение товара в корзину (42303 раза, совершенных 3734 пользователями, что составляет 49,6% от их общего количества), оплата товара (33918 раз, совершенных 3539 пользователями, что составляет 47,0% от их общего количества) и посещение страницы с инструкцией по работе приложения (1005 раз, совершенных 840 пользователями, что составляет 11,1% от их общего количества).
События, совершенные пользователями, выстраиваются в последовательную цепочку в следующем порядке:
Если не учитывать порядок действий пользователей в приложении, то наибольшее снижение количества пользователей происходит на этапе посещения страницы с предложениями товара по сравнению с посещением главной страницы - 38,1%. При последовательном движении по цепочке событий больше всего пользователей теряется при переходе со страницы с предложениями товара к корзине с товаром - 57,3%.
Из всех пользователей, посетивших главную страницу приложения, 47,7% произвели оплату товара, а всю последовательную цепочку событий от посещения главной страницы до оплаты товара прошли только 18,3% пользователей.
Для ответа на вопрос о влиянии изменения шрифта в мобильном приложении на поведение пользователей был проведен эксперимент, при этом пользователи были распределены на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Контрольные группы приняли участие в проведении успешного A/A-теста, в результате которого установлено:
Успешный А/А тест позволил приступить к проведению А/В теста, в котором экспериментальная группа сравнивалась по каждому шагу цепочки событий с каждой из контрольных групп, а также объединенной контрольной группой. Во время проведения эксперимента было сделано 12 проверок статистических гипотез, при этом ни одна из проведенных проверок не показала наличие статистически значимых различий, вследствие чего отсутствуют основания считать, что изменение шрифта в мобильном приложении влияет на поведение пользователей.
Полученные при проверке гипотез во время А/В теста уровни p-значения во всех случаях оказались больше установленного уровня статистической значимости в 5% с учетом поправки на множественные сравнения, при этом увеличение установленного уровня значимости, например до 10%, никак не повлияло бы на результаты эксперимента, выводы по всем проверкам статистических гипотез оказались бы неизменными.